250608 源生?启动!

前言

预览

使用如下代码:

Flow.Create()
    .Then(() => GetComponent<SpriteRenderer>().ToCol(Color.black).Easing(Transition.InOutElastic).During(5).Play())
    .Then(() => transform.FromPos(Vector3.left * 5 + Vector3.up * 2).ToPos(Vector3.right * 5 + Vector3.up * 2)
                         .ToRot(Vector3.forward * -360)
                         .During(2).Play())
    .Delay(() => transform.AddPos(Vector3.down * 4).During(2).Easing(Transition.OutBounce))
    .Then(() => transform.AddRot(Vector3.forward * 1440).During(3).Easing(Transition.InQuad).Play())
    .Then(() => transform.AddScale(Vector3.one * 2).During(3).Play())
    .Run();

实现这样的效果:

预览

起因

由于devkit中的Flow很好用,我尝试不那么严谨地复现它。注意到Flow依赖于Tween,这不禁让我想起DOTween。虽然我没有用过DOTween,但是见过它看起来很便捷的拓展方法。于是重心从复现Flow转移到了土制魔改版DOTween。

在实现过程中,我发现其中有不少重复的工作。

以SpriteRenderer为例,我们需要:

如果要对它的color变量进行操作,我们需要:

试想我们要扩展的组件不止SpriteRenderer,还有Transform;要控制的变量不止color,还有position、rotation……而上面提到的工作都是机械式的操作,并且确实可以由机械来操作,我们自然而然地会把目光投向代码生成。

代码生成有许多种方法,devkit的AudioMap里直接当做写文本文件然后改后缀叫代码生成,T4 Template也叫代码生成,源代码生成器也叫代码生成。只是我比较偏好最新最热的源代码生成器,所以选择了它。

好吧我承认假如“源生”指的是T4 Template我应该会首先考虑这种老东西。。。

简介

参照某乎上的一篇文章

源代码生成器是在 C# 9 中引入的一个特性,它允许在编译过程中动态生成代码。它们直接与 C# 编译器 (Roslyn) 集成,并在编译时操作,分析源代码并基于分析结果生成附加代码。 源代码生成器提供了一种流线型(我总感觉这儿应该是“管道式”)的、自动化的代码生成方法,消除了对外部工具或单独预编译步骤的需求。

通过无缝集成到编译过程中,源代码生成器增强了生产力,减少了错误,并允许更高效的开发工作流。

虽然本人是参照这篇文档在Unity6里面操作的,但是根据另一篇文档,早在2021.3中,Unity就支持源生成器了。不过不同Unity版本的支持程度不同,例如前者说“您的源代码生成器必须使用Microsoft.CodeAnalysis.Csharp 4.3才能与 Unity 协同工作”,而后者是Microsoft.CodeAnalysis.Csharp 3.8,需要根据版本仔细鉴别。

正文

目标

以SpriteRenderer为例,为了改变SpriteRenderer的color,我们需要编写如下两个脚本:

// SpriteRendererTransition.cs
using UnityEngine;

public class SpriteRendererTransition : Transition
{
    private readonly SpriteRenderer target;
            
    private Color col, fromCol, toCol;
    public SpriteRendererTransition FromCol(Color col)
    {
        this.col = fromCol = col;
        return this;
    }
    public SpriteRendererTransition ToCol(Color col)
    {
        toCol = col;
        return this;
    }
                
    public SpriteRendererTransition(SpriteRenderer target) : base()
    {
        this.target = target;
        this.col = fromCol = toCol = target.color;
    }

    protected override void Init()
    {
        base.Init();
        target.color = col;
    }

    protected override void Process(float delta)
    {
        base.Process(delta);
        if (!target)
        {
            Drop();
            return;
        }
        target.color += delta * (toCol - fromCol);
    }
}
// SpriteRendererTransitionExtensions.cs
using UnityEngine;

public static class SpriteRendererTransitionExtensions
{           
    public static SpriteRendererTransition FromCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(col);
    public static SpriteRendererTransition ToCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(col);
    public static SpriteRendererTransition AddCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(target.color + col);
}

南无三,多么冗长的代码!为了改变SpriteRenderer的color,我们真正需要关注的,事实上只有Init与Process方法的最后一句而已!

我希望可以只写这么几行,就能完成对一个组件(里面我们关注的属性)的拓展:

// SpriteRendererTransition.cs
using UnityEngine;

namespace SubstanceP
{
    [TransitionTarget(typeof(SpriteRenderer))]
    public partial class SpriteRendererTransition : Transition
    {
        // 虽然这个字符串找属性的设计有点问题,但是把它当成编译期反射就好接受多了(?)
        // 我也是在这个时候才明白为什么它在某些场景中是反射的上位替代
        [TransitionField("color")] private Color col;
        private partial void InitCol(SpriteRenderer target) => target.color = col;
        private partial void ProcessCol(SpriteRenderer target, Color delta) => target.color += delta;
    }
}

相应的,以下脚本应该自动生成:

// SpriteRendererTransition.g.cs
using UnityEngine;

namespace SubstanceP
{
    public partial class SpriteRendererTransition : Transition
    {
        private readonly SpriteRenderer target;
                
        private Color fromCol, toCol;
        private partial void InitCol(SpriteRenderer target);
        private partial void ProcessCol(SpriteRenderer target, Color delta);
        public SpriteRendererTransition FromCol(Color col)
        {
            this.col = fromCol = col;
            return this;
        }
        public SpriteRendererTransition ToCol(Color col)
        {
            toCol = col;
            return this;
        }
                    
        public SpriteRendererTransition(SpriteRenderer target) : base()
        {
            this.target = target;
            this.col = fromCol = toCol = target.color;
        }
        protected override void Init()
        {
            base.Init();    
            InitCol(target);
        }
        protected override void Process(float delta)
        {
            base.Process(delta);
            if (!target)
            {
                Drop();
                return;
            } 
            ProcessCol(target, delta * (toCol - fromCol));
        }
    }
}
// SpriteRendererExtensionsForTransition.g.cs
using UnityEngine;

namespace SubstanceP
{
    public static class SpriteRendererExtensionsForTransition
    {
        public static SpriteRendererTransition FromCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(col);
        public static SpriteRendererTransition ToCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(col);
        public static SpriteRendererTransition AddCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(target.color + col); 
    }
}

实现

环境

创建 .NET Standard 2.0 类库项目之后,导入Microsoft.CodeAnalysis.CSharp,然后以文本方式打开.csproj文件,将这个包的版本降到你的Unity支持的版本。可能还有些别的问题,总之一通操作下来,这个.csproj文件应该长这样:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <LangVersion>9.0</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
  </ItemGroup>

</Project>

接下来才是紧张刺激的编码环节。

由于源生成器依赖于Roslyn语法分析,而相关文档和分享有些零散,有实质内容的通常深入而不浅出,主角还往往是Unity6都不支持的IIncrementalGenerator……总之查资料性质的操作基本都交给AI了。

源生

ISourceGenerator接口要求实现:

前者用于筛选出我们感兴趣的语法,后者根据筛选出来的语法,在方法体中调用GeneratorExecutionContext.AddSource(string, SourceText)添加脚本,达成代码生成的目的。

Initialize

创建一个继承自ISyntaxReceiver的类,用于接收我们感兴趣的语法:

对于一些比较繁琐的操作,考虑到代码可读性以及功能的可复用性,我们把它封装进拓展方法:

// 这个类里面有一大堆Roslyn相关的东西,十分复杂,我也讲不明白
internal static class SyntaxExtensions
{
    internal static bool DirectlyInheritsFrom(this ClassDeclarationSyntax node, string name)
        => node.BaseList != null && node.BaseList.Types.Any(t => t.ToString() == name);

    internal static bool IsPartialClass(this ClassDeclarationSyntax node)
        => node.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
        
    internal static bool HasAttribute(this MemberDeclarationSyntax node, string name)
        => node.AttributeLists.Any(al => al.Attributes.Any(a => a.Name.ToString() == name));
}

这样一来,主要逻辑就清晰了:

internal class TransitionSyntaxReceiver : ISyntaxReceiver
{
    internal List<ClassDeclarationSyntax> Transitions { get; } = new();
    internal List<FieldDeclarationSyntax> Fields { get; } = new();
    public void OnVisitSyntaxNode(SyntaxNode node)
    {
        if (node is ClassDeclarationSyntax classNode &&
            classNode.HasAttribute("TransitionTarget") &&
            classNode.IsPartialClass() &&
            classNode.DirectlyInheritsFrom("Transition"))
            Transitions.Add(classNode);

        if (node is FieldDeclarationSyntax fieldNode &&
            fieldNode.HasAttribute("TransitionField"))
            Fields.Add(fieldNode);
    }
}

在Initialize方法中注册这个语法接收器:

public void Initialize(GeneratorInitializationContext ctx) =>
    ctx.RegisterForSyntaxNotifications(() => new TransitionSyntaxReceiver());

Execute

由于我们只处理自己感兴趣的语法,所以如果我们拿到的不是上一步写的语法接收器就直接返回:

if (ctx.SyntaxReceiver is not TransitionSyntaxReceiver receiver) return;

由于我们要生成两个脚本,一个部分类,一个扩展方法,而前者包含ctor、Init、Process三个需要动态调整的方法,所以我们需要准备五个StringBuilder:

StringBuilder partialBuilder = new(), extensionBuilder = new(), ctorBuilder = new(), initBuilder = new(), processBuilder = new();

我们需要对每一个符合条件的类分别处理,以故需要遍历TransitionSyntaxReceiver.Transitions中每一个ClassDeclarationSyntax,每轮遍历通过一个脚本产生两个脚本

foreach (var cls in receiver.Transitions) // ...

对于一个人工写的脚本,它的开头包含以下信息:

internal static string GetNamespace(this SyntaxNode node)
{
    var current = node;
    while (current is not NamespaceDeclarationSyntax)
    {
        current = current.Parent;
        if (current == null) return string.Empty;
    }
    return (current as NamespaceDeclarationSyntax).Name.ToString();
}

internal static AttributeSyntax GetAttribute(this MemberDeclarationSyntax node, string name)
{
    foreach (var al in node.AttributeLists)
        foreach (var a in al.Attributes)
            if (a.Name.ToString() == name) return a;
    return null;
}

internal static T GetUnique<T>(this AttributeSyntax node) where T : ExpressionSyntax
    => node.ArgumentList.Arguments.Select(a => a.Expression).OfType<T>().First();
    
internal static IEnumerable<string> GetUsingsInFile(this ClassDeclarationSyntax node)
    => node.SyntaxTree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>().Select(u => u.Name.ToString());
// 清空StringBuilder处理完上一个脚本的残余
partialBuilder.Clear();
extensionBuilder.Clear();
ctorBuilder.Clear();
initBuilder.Clear();
processBuilder.Clear();

// 拿到这个脚本的上述信息
string className = cls.Identifier.Text, namesp = cls.GetNamespace(); // SpriteRendererTransition, SubstanceP
var targetType = cls.GetAttribute("TransitionTarget").GetUnique<TypeOfExpressionSyntax>().Type; // SpriteRenderer
var starting = namesp == string.Empty ? string.Empty : $"namespace {namesp}\n{{";
var us = cls.GetUsingsInFile(); // 包含UnityEngine的数组

// 朴素的文本填充
// 引用的命名空间
foreach (var u in us)
{
    partialBuilder.AppendLine($"using {u};");
    extensionBuilder.AppendLine($"using {u};");
}
// 部分类的命名空间、名称、目标类型
partialBuilder.Append($@"
{starting}
    public partial class {className} : Transition
    {{
        private readonly {targetType} target;
");
// 拓展方法的命名空间、名称、目标类型
extensionBuilder.Append($@"
{starting}
    public static class {targetType}ExtensionsForTransition
    {{
");
// ctor
ctorBuilder.Append($@"
        public {className}({targetType} target) : base()
        {{
            this.target = target;
                ").AppendLine();
// Init
initBuilder.Append($@"
        protected override void Init()
        {{
            base.Init();
").AppendLine();
// Process
processBuilder.Append($@"
        protected override void Process(float delta)
        {{
            base.Process(delta);

            if (!target)
            {{
                Drop();
                return;
            }}
").AppendLine();

接下来需要对每一个符合条件的字段进行处理,以故需要遍历TransitionSyntaxReceiver.Fields中每一个FieldDeclarationSyntax:

foreach (var field in receiver.Fields) // ...

判断当前字段是否属于本轮遍历的类:

internal static string GetClass(this FieldDeclarationSyntax node)
    => (node.Parent as ClassDeclarationSyntax).Identifier.Text;
if (field.GetClass() != className) continue;

对于每一个字段,我们需要知道它的:

var fieldType = field.Declaration.Type; // Color
var fieldName = field.Declaration.Variables.First().Identifier.Text; // col
// Col,用于后续的fromCol与toCol
var fieldNameNew = char.ToUpper(fieldName[0]) + fieldName.Substring(1);
// color,SpriteRenderer组件上的color属性
var relative = field.GetAttribute("TransitionField").GetUnique<LiteralExpressionSyntax>().Token.ValueText;

然后就是朴素的字符串拼接环节了:

partialBuilder.Append($@"
private {fieldType} from{fieldNameNew}, to{fieldNameNew};
private partial void Init{fieldNameNew}({targetType} target);
private partial void Process{fieldNameNew}({targetType} target, {fieldType} delta);
public {className} From{fieldNameNew}({fieldType} {fieldName})
{{
    this.{fieldName} = from{fieldNameNew} = {fieldName};
    return this;
}}
public {className} To{fieldNameNew}({fieldType} {fieldName})
{{
    to{fieldNameNew} = {fieldName};
    return this;
}}
");

extensionBuilder.Append($@"
public static {className} From{fieldNameNew}(this {targetType} target, {fieldType} {fieldName}) => new {className}(target).From{fieldNameNew}({fieldName});
public static {className} To{fieldNameNew}(this {targetType} target, {fieldType} {fieldName}) => new {className}(target).From{fieldNameNew}(target.{relative}).To{fieldNameNew}({fieldName});
public static {className} Add{fieldNameNew}(this {targetType} target, {fieldType} {fieldName}) => new {className}(target).From{fieldNameNew}(target.{relative}).To{fieldNameNew}(target.{relative} + {fieldName});
");

// 这意味着,每多一个变量,这三个方法就得多加一行,以故不能顺序写入,需要单独开一个StringBuilder
ctorBuilder.AppendLine($"\t\t\tthis.{fieldName} = from{fieldNameNew} = to{fieldNameNew} = target.{relative};");
initBuilder.AppendLine($"\t\t\tInit{fieldNameNew}(target);");
processBuilder.AppendLine($"\t\t\tProcess{fieldNameNew}(target, delta * (to{fieldNameNew} - from{fieldNameNew}));");

然后是汇总和结尾:

// 方法体结尾
ctorBuilder.AppendLine("\t\t}");
initBuilder.AppendLine("\t\t}");
processBuilder.AppendLine("\t\t}");
// 将方法添加到类中
partialBuilder.Append(ctorBuilder.ToString());
partialBuilder.Append(initBuilder.ToString());
partialBuilder.Append(processBuilder.ToString());
// 有命名空间会多一层花括号
var ending = "\n\t}" + (namesp == string.Empty ? string.Empty : "\n}");
partialBuilder.Append(ending);
extensionBuilder.Append(ending);

最后通过AddSource方法添加脚本:

ctx.AddSource($"{targetType}Transition.g.cs", SourceText.From(partialBuilder.ToString(), Encoding.UTF8));
ctx.AddSource($"{targetType}ExtensionsForTransition.g.cs", SourceText.From(extensionBuilder.ToString(), Encoding.UTF8));

如何将它应用到Unity中,查阅手册即可。

检验

在哪个项目中人工编写脚本,就能在哪个项目的引用-分析器-TransitionSourceGenerator-SubstanceP.TransitionSourceGenerator中找到生成的相应脚本。 例如,我在Scripts文件夹中写了个AudioSource.cs:

// AudioSource.cs
using UnityEngine;
using SubstanceP;

[TransitionTarget(typeof(AudioSource))]
public partial class AudioSourceTransition : Transition
{
    [TransitionField("volume")] private float vol;
    private partial void InitVol(AudioSource target) => target.volume = vol;
    private partial void ProcessVol(AudioSource target, float delta) => target.volume += delta;
}

它不在Plugins或者Editor文件夹中,所以属于Assembly-CSharp项目,故生成的脚本应该在这个位置:

脚本位置

完整的项目放在这里:

https://github.com/SubstanceP114/Transition

https://github.com/SubstanceP114/TransitionSourceGenerator

如果希望了解更多细节,或者你也想启动源生,那就点进去看看吧!


参考

https://github.com/BingyanStudio/BingyanDevKit/tree/main/Runtime

https://zhuanlan.zhihu.com/p/690915950

https://docs.unity3d.org.cn/6000.0/Documentation/Manual/create-source-generator.html

https://docs.unity.cn/cn/2021.3/Manual/roslyn-analyzers.html

https://github.com/SubstanceP114/Transition

https://github.com/SubstanceP114/TransitionSourceGenerator